ENG-3301: Add threading header support to email providers (Story 2)#8122
Conversation
Introduce BaseMessageProviderService / BaseEmailProviderService / BaseSMSProviderService ABCs and migrate Mailgun, TwilioEmail, MailchimpTransactional, and TwilioSMS dispatchers to provider classes. AWS SES remains on the legacy dispatcher and will be migrated in a follow-up PR. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
acc05e5 to
ec08848
Compare
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
916b01c to
64bee10
Compare
SES is intentionally excluded from _resolve_provider_map() and PROVIDER_MAP since it uses the legacy dispatcher until PR2. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract AwsSesService from the legacy aws_ses_service module into the new messaging_providers package. Adds SES identity validation on config save, removes the legacy _aws_ses_dispatcher fallback, and deletes the old src/fides/service/messaging/aws_ses_service.py module. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The old fides.service.messaging.aws_ses_service module is deleted in this PR, so the import must be removed. Also updates comments now that SES is in the provider map. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ec08848 to
b92ef93
Compare
Extend EmailForActionType with optional reply_to, message_id, in_reply_to, references, and body_text fields. Map these to provider-specific APIs in all 4 email providers: - Mailgun: h:Reply-To, h:Message-ID, etc. + text field - Twilio/SendGrid: ReplyTo, Header objects + Content text/plain - Mailchimp: message.headers dict + message.text - AWS SES: switch to send_raw_email with MIME construction via email.message.EmailMessage for custom header support Add PROVIDER_MAP completeness invariant test and per-provider header mapping tests including non-ASCII encoding coverage. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
64bee10 to
f07ce63
Compare
Resolve merge conflicts between story 2 (threading headers, MIME-based SES sending) and story 1 base refactor (provider helpers, error handling, validate_on_save). Adopts main's infrastructure, preserves story 2 features. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #8122 +/- ##
==========================================
+ Coverage 85.45% 85.46% +0.01%
==========================================
Files 650 650
Lines 42363 42416 +53
Branches 4971 4980 +9
==========================================
+ Hits 36201 36252 +51
- Misses 5054 5055 +1
- Partials 1108 1109 +1 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
|
/code-review |
There was a problem hiding this comment.
Code Review: PR #8122 — Threading header support for email providers
Good overall approach — the abstraction is clean and the per-provider mapping is well-structured. Two latent correctness bugs should be addressed before any caller populates the new fields (ENG-3299), plus a few minor suggestions.
Bugs (latent — no caller sets body_text or threading fields yet)
1. SES: EmailMessage() without policy=email.policy.SMTP will produce UTF-8 headers that SES classic rejects for non-ASCII subjects
EmailMessage() with no policy defaults to EmailPolicy (RFC 6532 / SMTPUTF8), which writes non-ASCII header values as raw UTF-8. SES classic's send_raw_email expects RFC 2822 with RFC 2047 encoded-word encoding. The test_non_ascii_subject_encoding test only round-trips through Python and won't catch this SES rejection. See inline comment on aws_ses_service.py:156.
2. Twilio/SendGrid: add_content(text/plain) appends plain text after HTML, inverting RFC 2046 preference order
_compose_mail initializes Mail with Content("text/html", ...) first, then add_content(Content("text/plain", ...)) appends after it. In multipart/alternative, the last part is the most preferred — so clients that respect RFC 2046 will render plain text instead of HTML. See inline comment on twilio_email_service.py:67.
Minor Suggestions
mailchimp_transactional_service.py:39: Mandrill has a nativereply_tomessage field; setting it viaheaders["Reply-To"]works but is non-canonical. Minor correctness concern.aws_ses_service.py:35: Protocol stub return typedict[str, str]is inaccurate — should bedict[str, Any]to match the actual boto3 response structure.messaging.py:315: New fields usestr | Nonewhile the rest ofEmailForActionTypeusesOptional[str]. Minor style inconsistency worth making consistent.
Positives
- The
_PROVIDER_MAPcompleteness test (test_provider_map.py) is a great invariant — it'll catch missing providers automatically as newMessagingServiceTypevalues are added. - Per-provider tests cover both the "headers included" and "headers omitted" cases cleanly.
- The
except MessageDispatchException: raiseguard insend_emailprevents double-wrapping of already-typed exceptions — good catch. - Backward compatibility is solid: all new fields default to
Noneand are additive to the schema.
🔬 Codegraph: unavailable
💡 Write /code-review in a comment to re-run this review.
- SES: add policy=email_policy.SMTP for RFC 2047 non-ASCII header encoding - Twilio/SendGrid: fix RFC 2046 content ordering (text/plain before text/html) - Mailchimp: use Mandrill's native reply_to field instead of headers dict - SES: fix Protocol stub return type to dict[str, Any] Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move body_text handling into _compose_mail to build the content list in correct RFC 2046 order from the start, using the content setter (which appends) instead of assigning to contents (which has no setter). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Test was asserting reply_to in headers dict, but we moved it to Mandrill's native msg_payload["reply_to"] field in the review feedback. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| message_id: str | None = None # RFC 5322 Message-ID | ||
| in_reply_to: str | None = None | ||
| references: str | None = None | ||
| body_text: str | None = None # plaintext alternative |
There was a problem hiding this comment.
is body HTML then? maybe a comment next to it would also help clarify the difference between body and body_text
There was a problem hiding this comment.
Done — added inline comments clarifying body is HTML and body_text is the plaintext alternative for multipart/alternative.
|
|
||
| @staticmethod | ||
| def _build_mime( | ||
| from_address: str, to: str, message: EmailForActionType |
There was a problem hiding this comment.
is there anything AWS SES specific here? if not can we make this a method on the base class?
There was a problem hiding this comment.
Moved _build_mime to BaseEmailProviderService.build_mime() so any MIME-based provider (SES, future SMTP/Gmail) can reuse it. Also extracted get_threading_headers(message, header_prefix) on the base class — all four providers now use it instead of duplicating the header logic.
| msg_payload: dict = { | ||
| "from_email": self.from_email, | ||
| "subject": message.subject, | ||
| "html": message.body, | ||
| "to": [{"email": to.strip(), "type": "to"}], | ||
| } | ||
|
|
||
| # Reply-to uses Mandrill's native field | ||
| if message.reply_to: | ||
| msg_payload["reply_to"] = message.reply_to | ||
|
|
||
| # Threading headers go in the headers dict (no native Mandrill equivalents) | ||
| headers = {} | ||
| if message.message_id: | ||
| headers["Message-ID"] = message.message_id | ||
| if message.in_reply_to: | ||
| headers["In-Reply-To"] = message.in_reply_to | ||
| if message.references: | ||
| headers["References"] = message.references | ||
| if headers: | ||
| msg_payload["headers"] = headers | ||
| if message.body_text: | ||
| msg_payload["text"] = message.body_text |
There was a problem hiding this comment.
can we not use _build_mime here? if not, is there common logic we could extract? e.g the headers
def get_headers(message):
headers = {}
if message.reply_to:
headers["Reply-To"] = message.reply_to
if message.message_id:
headers["Message-ID"] = message.message_id
if message.in_reply_to:
headers["In-Reply-To"] = message.in_reply_to
if message.references:
headers["References"] = message.references
return headersThere was a problem hiding this comment.
Done — extracted get_threading_headers() on BaseEmailProviderService. Mailchimp uses it for the headers dict; reply_to stays on the native Mandrill field.
| if message.reply_to: | ||
| data["h:Reply-To"] = message.reply_to | ||
| if message.message_id: | ||
| data["h:Message-ID"] = message.message_id | ||
| if message.in_reply_to: | ||
| data["h:In-Reply-To"] = message.in_reply_to | ||
| if message.references: | ||
| data["h:References"] = message.references |
There was a problem hiding this comment.
okay -- maybe get_headers(message, header_prefix = None) so we can do something like:
def get_headers(message, header_prefix):
headers = {}
prefix = ""
if header_prefix:
prefix = f"{header_prefix}:"
if message.reply_to:
headers[f"{prefix}Reply-To"] = message.reply_to
if message.message_id:
headers[f"{prefix}Message-ID"] = message.message_id
if message.in_reply_to:
headers[f"{prefix}In-Reply-To"] = message.in_reply_to
if message.references:
headers[f"{prefix}References"] = message.references
return headersThere was a problem hiding this comment.
Done — Mailgun uses self.get_threading_headers(message, header_prefix='h:') to get prefixed keys. All providers now share the same helper.
Co-authored-by: Eliana Rosselli <67162025+erosselli@users.noreply.github.com>
…rvice.py Co-authored-by: Eliana Rosselli <67162025+erosselli@users.noreply.github.com>
- Clarify body (HTML) vs body_text (plaintext) with inline comments - Extract get_threading_headers() and build_mime() to BaseEmailProviderService - All four providers now use shared header helper instead of duplicating logic - Mailgun uses header_prefix="h:" for prefixed keys - Mailchimp keeps native reply_to field, uses helper for remaining headers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
erosselli
left a comment
There was a problem hiding this comment.
approved with some comments
| if message.body_text: | ||
| msg.set_content(message.body_text) | ||
| msg.add_alternative(message.body, subtype="html") | ||
| else: | ||
| msg.set_content(message.body, subtype="html") |
There was a problem hiding this comment.
Maybe we should include a commen on why we prefer raw text over html?
There was a problem hiding this comment.
Done — added a comment explaining the RFC 2046 multipart/alternative ordering convention.
|
|
||
| # Threading headers (exclude Reply-To — handled natively above) | ||
| threading_headers = self.get_threading_headers(message) | ||
| threading_headers.pop("Reply-To", None) |
There was a problem hiding this comment.
I think we should use an enum or at least constants for the header names ? e.g it'd be easy to mess up and write reply-to or Reply-to etc.
There was a problem hiding this comment.
Done — extracted header name constants (HEADER_REPLY_TO, HEADER_MESSAGE_ID, etc.) on BaseEmailProviderService and updated all provider references (here, twilio_email_service.py, and get_threading_headers()).
| mail.content = Content("text/plain", body_text) | ||
| mail.content = Content("text/html", message_body) |
There was a problem hiding this comment.
does this actually append both contents?
There was a problem hiding this comment.
Yes — the Mail.content setter appends (calls add_content() internally), so both parts are included. This builds a multipart/alternative with text/plain and text/html, same as build_mime() in the base class.
If body_text isn't populated, it just sends HTML like before — no behavior change. The plaintext path is for recipients whose email client or server prefers plain text — Fides just provides both options in the envelope so whatever's on the other end can pick. Examples:
- Accessibility-focused email setups
- Corporate environments with strict HTML filtering
- Plain-text-preferred mail clients
| from fides.api.service.messaging.messaging_providers.mailgun_service import ( | ||
| MailgunService, | ||
| ) |
There was a problem hiding this comment.
should we do top level imports in this file too?
There was a problem hiding this comment.
Done — moved all provider imports to module top level. Also checked all other PR files — no other local imports found.
| def test_all_service_types_have_providers(self): | ||
| assert set(_PROVIDER_MAP.keys()) == set(MessagingServiceType) |
- Add RFC 2046 comment explaining multipart/alternative ordering - Extract header name constants on BaseEmailProviderService - Move test imports to module top level Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…8122) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Eliana Rosselli <67162025+erosselli@users.noreply.github.com>
Ticket ENG-3301
Description Of Changes
Adds threading header support to all 4 email providers. Extends
EmailForActionTypewith optionalreply_to,message_id,in_reply_to,references, andbody_textfields. All fields default toNone— existing callers are unaffected (backward compatible).Switches AWS SES from
send_email()(simple API) tosend_raw_email()with MIME construction viaemail.message.EmailMessagefor custom header support.Code Changes
EmailForActionTypeschema with 5 optional threading/envelope fieldsh:Reply-To,h:Message-ID,h:In-Reply-To,h:References,textReplyTo,Headerobjects,Content("text/plain", ...)message.headersdict,message.textsend_raw_email()withEmailMessageMIME constructionSESClienttype stub:send_email()→send_raw_email()test_provider_headers.py— per-provider header mapping tests (headers present + absent)test_provider_map.py—_PROVIDER_MAPcompleteness invariant testSteps to Confirm
1. Server starts cleanly
Confirm no import errors from the new/updated messaging provider modules.
2. Backward compatibility — send a test email
Set up a Mailgun config (or use whichever provider you have credentials for) and send a test message. Since no callers populate the new threading fields yet, this should behave identically to before the PR:
Verify 200 response and email arrives. Open the received email's raw source (Gmail: ⋮ → "Show original") and confirm there are no
In-Reply-To,References, or unexpectedReply-Toheaders — the new fields default toNoneand should be omitted entirely.3. SES MIME migration
This is the riskiest change — SES now builds a full MIME message via
email.message.EmailMessageand sends it withsend_raw_email()instead of the simplesend_email()API. Set up an SES config:Verify 200 response and email arrives. Open the raw source and confirm:
Content-Typeistext/html(single-part, sincebody_textis not set)From,To,Subjectheaders are present and correct4. Threading header coverage (automated)
The new threading fields (
reply_to,message_id,in_reply_to,references,body_text) can't be exercised via the test message API — no caller populates them yet. That's intentional; this PR lays infrastructure for ENG-3299.test_provider_headers.pycovers all 4 email providers with two tests each:h:Reply-To, SendGridHeaderobjects, Mailchimpmessage.headersdict, SES MIME headers)None, asserts no extra headers appear in the outgoing request/messageAdditionally, SES has two encoding tests confirming non-ASCII subjects and bodies survive the MIME round-trip.
To run locally:
Pre-Merge Checklist
CHANGELOG.mdupdated🤖 Generated with Claude Code